内容相关开发:完成课程+标签嵌套 CRUD(事务嵌套 Bug)
本节是课程模块开发的收尾部分。我们将完成更新标签的逻辑、整合课程创建与标签创建的事务处理,并解决 Prisma 不支持嵌套事务(Transaction inside Transaction)的问题——这是一个在实际开发中常见但官方尚未原生支持的场景。
更新标签:先删后建策略
更新课程标签的逻辑比创建更简单,采用"先删除所有旧标签,再重新创建"的策略:
// course/course.service.ts
async updateTag(dto: UpdateCourseTagDto) {
return this.prisma.$transaction(async (prisma) => {
// 1. 删除该课程的所有标签关联
await prisma.courseTag.deleteMany({
where: { courseId: dto.courseId },
});
// 2. 重新创建标签(复用 createTag 方法)
return this.createTag(
dto as CreateCourseTagDto,
prisma, // 传入事务客户端
);
});
}
typescript
关键点: createTag 方法需要接受可选的 Prisma 客户端参数,确保内部操作使用与外部事务相同的客户端实例:
async createTag(
dto: CreateCourseTagDto,
prisma?: TransactionClient, // 可选的事务客户端
) {
const tx = prisma || this.prisma;
// 所有数据库操作使用 tx 而非 this.prisma
}
typescript
课程创建整合标签
创建课程时同时处理标签,需要在事务中完成:
async create(dto: CreateCourseDto) {
const { tags, ...restData } = dto;
return this.prisma.$transaction(async (prisma) => {
// 1. 创建课程
const course = await prisma.course.create({
data: restData,
});
// 2. 处理标签(仅在 tags 存在且非空时)
if (tags && Array.isArray(tags) && tags.length > 0) {
// 注意:传入的是 CreateDictCourseTagDto,不是 CreateCourseTagDto
// 因为此时 courseId 刚生成,需要手动拼接
const tagDto: CreateCourseTagDto = {
courseId: course.id,
tags,
} as CreateCourseTagDto;
await this.createTag(tagDto, prisma);
// 重新查询,包含标签数据
return prisma.course.findUnique({
where: { id: course.id },
include: { tags: { include: { tag: true, courseType: true } } },
});
}
return course;
});
}
typescript
注意事项:
createTag的 DTO 参数类型需要调整,因为创建课程时courseId尚未生成- 传入的
tags实际上是CreateDictCourseTagDto[],需手动补充courseId后再传给createTag
Prisma 嵌套事务 Bug
问题描述
当课程创建(外层 $transaction)内部调用 createTag(内层也使用 $transaction)时,Prisma 会抛出错误:
TypeError: prisma.$transaction is not a function
text
根因: Prisma 的事务客户端(TransactionClient)不支持在其上再调用 $transaction——即不支持事务嵌套。
官方 Issues 参考
- Prisma # manually-nested-transactions
- Prisma 官方尚未原生支持嵌套事务
解决方案:Proxy 代理透传
核心思路是使用 JavaScript Proxy 拦截事务客户端的 $transaction 访问,将其透传到外层 Prisma 实例:
// common/utils/prisma-utils.ts
import { Prisma } from '@prisma/client';
/**
* 扩展事务客户端,支持嵌套事务
* 当内层代码调用 tx.$transaction 时,自动透传到外层的 prisma 实例
*/
export function extendTransaction(tx: Prisma.TransactionClient) {
return new Proxy(tx, {
get(target, prop) {
if (prop === '$transaction') {
// 透传到外层的 $transaction
return target[prop];
}
return target[prop as keyof typeof target];
},
});
}
typescript
在 Prisma Module 的工厂函数中应用:
// common/database/prisma-client.module.ts
import { Global, Module } from '@nestjs/common';
import { extendTransaction } from '../utils/prisma-utils';
@Global()
@Module({
providers: [
{
provide: 'PRISMA_CLIENT',
useFactory: () => {
const prisma = new PrismaClient();
// 包装 createTag 等方法中使用的事务客户端
const originalTransaction = prisma.$transaction.bind(prisma);
prisma.$transaction = (...args: any[]) => {
if (typeof args[0] === 'function') {
return originalTransaction(async (tx) => {
const extendedTx = extendTransaction(tx);
return args[0](extendedTx);
});
}
return originalTransaction(...args);
};
return prisma;
},
},
],
exports: ['PRISMA_CLIENT'],
})
export class PrismaClientModule {}
typescript
替代方案:AsyncLocalStorage
另一种方案使用 Node.js 的 AsyncLocalStorage 管理事务范围:
// common/utils/prisma-utils.ts
import { AsyncLocalStorage } from 'async_hooks';
import { Prisma, PrismaClient } from '@prisma/client';
const asyncLocalStorage = new AsyncLocalStorage<Prisma.TransactionClient>();
/**
* 使用 AsyncLocalStorage 管理事务客户端
* 嵌套调用时自动复用同一事务客户端
*/
export function createTransactionalPrismaClient(prisma: PrismaClient) {
return new Proxy(prisma, {
get(target, prop) {
const txClient = asyncLocalStorage.getStore();
const client = txClient || target;
if (prop === '$transaction') {
return (fn: (tx: Prisma.TransactionClient) => Promise<any>) => {
return target.$transaction(async (tx) => {
return asyncLocalStorage.run(tx, () => fn(tx));
});
};
}
return client[prop as keyof typeof client];
},
});
}
typescript
方案对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| Proxy 透传 | 实现简单,逻辑精确 | 需要手动在每个事务入口应用 |
| AsyncLocalStorage | 自动传播事务上下文 | 引入额外复杂度,调试困难 |
| Promise.all(无事务) | 简单直接 | 无法保证数据一致性 |
| 不使用嵌套事务 | 无额外代码 | 某些场景无法保证原子性 |
推荐方案: 对于标签创建这类对一致性要求不那么严格的场景,可以考虑不使用嵌套事务(即使标签创建失败,课程数据仍然有效)。对于严格需要原子性的场景,使用 Proxy 透传方案。
课程创建的最终实现
综合以上内容,课程创建的完整流程:
async create(dto: CreateCourseDto) {
const { tags, ...restData } = dto;
return this.prisma.$transaction(async (prisma) => {
// Step 1: 创建课程主体
const course = await prisma.course.create({
data: restData,
});
// Step 2: 如果有标签,创建标签关联
if (tags && Array.isArray(tags) && tags.length > 0) {
const tagDto = {
courseId: course.id,
tags,
};
// 使用 extendTransaction 包装事务客户端
const extendedPrisma = extendTransaction(prisma);
await this.createTag(tagDto as CreateCourseTagDto, extendedPrisma);
// Step 3: 重新查询返回完整数据
return prisma.course.findUnique({
where: { id: course.id },
include: {
tags: {
include: {
tag: true,
courseType: true,
},
},
},
});
}
return course;
});
}
typescript
测试验证
创建课程同时创建标签
POST /course
{
"name": "Java微服务实战",
"desc": "从零到一构建微服务架构",
"tags": [
{ "name": "Java", "type": { "name": "后端" } },
{ "name": "微服务", "type": { "name": "架构" } }
]
}
json
成功响应包含课程信息和嵌套的标签数据:
{
"id": 1,
"name": "Java微服务实战",
"desc": "从零到一构建微服务架构",
"tags": [
{
"courseId": 1,
"tagId": 5,
"tag": { "id": 5, "name": "Java" },
"courseType": { "id": 2, "name": "后端" }
},
{
"courseId": 1,
"tagId": 6,
"tag": { "id": 6, "name": "微服务" },
"courseType": { "id": 3, "name": "架构" }
}
]
}
json
小结
| 主题 | 要点 |
|---|---|
| 更新标签 | 先删后建策略,复用 createTag 方法 |
| 课程创建+标签 | 在 $transaction 中先创建课程获取 ID,再创建标签 |
| 嵌套事务问题 | Prisma 不支持 tx.$transaction(),需 Proxy 透传 |
| Proxy 方案 | 拦截 $transaction 访问,透传到外层 Prisma 实例 |
| AsyncLocalStorage | 替代方案,自动传播事务上下文 |
| 内容模块 | Content 和 Comment 的嵌套关联比 Course 简单,可参照实现 |
↑